前端能直接連資料庫時,資料庫的權限規則就是你唯一的一道牆。
USING 與 WITH CHECK
某個 AI 生成的服務用 Supabase 當資料庫。前端程式碼裡有 Supabase 的網址和公開金鑰,這很正常,Supabase 本來就這樣設計(第 3 天〈瀏覽器看得到的,全都是公開的〉)。
但有人把這兩個值複製出來,在自己的電腦上執行:
curl "https://xxxx.supabase.co/rest/v1/profiles?select=*" \
-H "apikey: <公開金鑰>" \
-H "Authorization: Bearer <公開金鑰>"
回傳的是整張會員資料表。
原因是那張表沒有開啟 RLS(Supabase 內建的資料權限規則,下面說明)。
這不是假設的情境。2025 年 3 月,研究者 Matt Palmer 掃描了 1,645 個用 AI 建站工具 Lovable 做出來的網站,其中 170 個(約 10.3%)有這個問題,共 303 個 API endpoint 可以直接讀寫資料,外洩的內容包括姓名、email、付款紀錄,以及開發者存在資料庫裡的第三方服務 API 金鑰。這個漏洞編號是 CVE-2025-48757。
Lovable 對這個編號提出異議,理由是:保護應用程式的資料,是每個使用 Lovable 的客戶自己的責任。不管你同不同意這個說法,結果都一樣:這道牆要你自己蓋。
傳統的網站架構是:前端 → 你的後端程式 → 資料庫。後端程式負責檢查「這個人能不能拿這筆資料」。
Supabase、Firebase 這類服務讓你省掉後端:前端直接跟資料庫說話。這讓開發變得超快,AI 生成的專案也常直接用這種架構。
代價是:原本後端做的檢查,現在全部要由資料庫的權限規則來做。
想像資料庫是一棟公寓,每一列資料是一個房間。沒有設定規則,就是所有房門都沒鎖,拿到大樓地址(公開金鑰)的人都能進每一間。
allow read, write: if request.time < timestamp.date(...),也就是 30 天內任何人都能讀寫,30 天後全部拒絕。到期那天服務突然壞掉,最省事的修法是直接刪掉日期條件,資料庫從此永久對外開放。Supabase:
rls_disabled_in_public(沒開 RLS)或 security_definer_view(view 會繞過 RLS,下面說明)這類錯誤。想用 SQL 確認的話,在 SQL Editor 執行這段,列出的就是沒開 RLS 的表:
select c.relname as table_without_rls
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
and c.relkind = 'r'
and not c.relrowsecurity;
Firebase:到 Firestore 或 Realtime Database 的「規則」分頁,搜尋三樣東西:
if true:所有人都能讀寫request.time <:測試模式的規則還沒換掉request.auth != null:只要登入就能讀寫所有人的資料。Firebase 官方文件也提醒,看到這個條件要確認你真的想讓每個登入的人都有權限。這個專案讓前端直接存取資料庫。請幫我盤點資料庫的權限規則,先不要修改:
1. 列出所有資料表(或集合),每一張標出是否啟用了 RLS(或 Security Rules)
2. 每一張表,分別說明「查詢、新增、修改、刪除」四種動作的規則是什麼,用白話說明誰可以做
3. 標出任何「所有人都能執行」或「只要登入就能執行」的規則
4. 標出「可以新增或修改資料,但沒有檢查資料擁有者欄位」的規則
5. 列出所有 view,標出沒有設定 security_invoker = true 的
6. 找出任何在前端程式碼中使用了「會繞過權限規則的高權限金鑰」的地方
(Supabase 的 secret key,sb_secret_ 開頭,或舊版的 service_role key)
修正時,把第 4 天〈登入了,不代表有權限〉的權限表一起給 AI,請它照表寫規則。
請 AI 幫你寫一支測試腳本(或自己照著做):
請寫一支測試腳本,只使用前端的公開金鑰、不登入,嘗試:
1. 查詢每一張資料表的全部資料
2. 新增一筆資料
然後再用使用者 A 登入,嘗試查詢、新增、修改、刪除使用者 B 的資料。
每一個修改或刪除的嘗試做完後,用管理權限重新查一次,確認 B 的資料真的沒有變。
把每一個嘗試的結果列成表格。
除了真正公開的資料,所有嘗試都應該失敗或回傳空的結果。
最後那個「重新查一次」不能省。RLS 擋下查詢、修改、刪除時,通常不會回傳錯誤:我在本機實測,被擋下的修改一樣回傳 HTTP 200,內容是空陣列,意思是「成功執行了,但一筆都沒改到」。真的改到和被擋下,都是 HTTP 200、沒有錯誤,只看狀態碼或錯誤訊息分不出來。
Supabase 透過 PostgREST 把 Postgres 的 schema 直接變成 REST API。請求帶的金鑰和登入狀態,決定它用哪個 Postgres 角色執行:
| 請求帶的東西 | Postgres 角色 | RLS |
|---|---|---|
publishable key(sb_publishable_ 開頭,舊稱 anon key),沒登入 |
anon |
受 RLS 限制 |
| publishable key,使用者已登入(帶 JWT) | authenticated,auth.uid() 取得使用者 id |
受 RLS 限制 |
secret key(sb_secret_ 開頭,舊稱 service_role key) |
service_role |
繞過 RLS |
一個請求能不能碰到資料,要過兩層:
permission denied for table。每一條 RLS policy 有兩個條件:
USING:哪些既有的列,這個角色看得到、能修改或刪除(作用在 SELECT、UPDATE、DELETE)WITH CHECK:寫進去之後的列必須滿足什麼條件(作用在 INSERT、UPDATE)UPDATE 的 policy 如果只寫 USING,Postgres 會拿同一個條件當 WITH CHECK,所以「把資料的擁有者改成別人」會被擋下(我在本機實測過)。真正的洞是 WITH CHECK 寫得太寬:寫成 with check (true),或只檢查角色是 authenticated。INSERT 沒有 USING 可以借,WITH CHECK 一定要自己檢查擁有者,這就是上面「只擋了讀,沒擋寫」的來源。
時程分兩段:
public schema 裡新建的表,不再自動 grant 給 anon、authenticated、service_role,前端查詢會得到 permission denied for table,要明確 grant 才能用。這讓「忘了開 RLS」的後果變小了,但沒有消失。RLS 預設還是關的,Supabase 的公告也寫明 grant 和 RLS 是兩層,grant 之後照樣需要 RLS。
我在本機重現過一個情況:前端出現 permission denied,錯誤訊息的 hint 寫著 GRANT SELECT ON public.your_table TO anon,AI 照著補上 grant,錯誤消失,功能正常,但那張表沒開 RLS,於是整張表又對外開放了。結果跟為了讓功能動起來而關掉 RLS 一樣。
grant 只給真正需要的角色。下面的範例沒有 grant 給 anon,沒登入的訪客連這張表都碰不到。
| 錯誤 | 後果 |
|---|---|
| 沒啟用 RLS | 有 grant 的角色可讀寫整張表(2026-05-30 之前建立的專案,anon 預設就有 grant) |
| 啟用了但沒寫 policy,全部被擋,開發者為了「修好」而關掉 RLS | 同上 |
出現 permission denied,照 hint 補了 grant,但沒開 RLS |
同上 |
using (true) |
policy 指定的角色能讀所有資料 |
INSERT 的 with check 沒檢查擁有者(true,或只檢查有登入) |
任何會員都能新增掛在別人名下的資料 |
UPDATE 寫了 with check (true) |
可以把自己的資料轉給別人 |
用前端傳來的欄位或 user_metadata 判斷身分,而不是 auth.uid() |
身分可偽造 |
建立 view 時沒有設定 security_invoker = true |
view 預設用建立者的權限執行,建立者通常是 postgres,繞過 RLS |
| 在前端或可被公開呼叫的 function 使用 secret key(或舊的 service_role key) | 完全繞過 RLS |
| Storage bucket 設為 public | 拿到檔案網址的人都能下載,下載不受 RLS 限制 |
view 那一條最容易被忽略,因為表本身的 RLS 寫得好好的。我在本機實測:同一個沒登入的請求,直接查表得到空陣列,查建立在這張表上的 view 卻拿到全部資料;把 view 改成 security_invoker = true 之後,結果才跟直接查表一致。這個選項從 PostgreSQL 15 開始才有。
alter table public.todo_lists enable row level security;
-- New Supabase projects no longer grant API roles access automatically.
-- Grant only what is needed: anonymous visitors get nothing here.
grant select, insert, update, delete on public.todo_lists to authenticated, service_role;
create policy "owners can read their lists"
on public.todo_lists for select
to authenticated
using (user_id = (select auth.uid()));
create policy "owners can create lists for themselves"
on public.todo_lists for insert
to authenticated
with check (user_id = (select auth.uid()));
create policy "owners can update their lists without transferring them"
on public.todo_lists for update
to authenticated
using (user_id = (select auth.uid()))
with check (user_id = (select auth.uid()));
create policy "owners can delete their lists"
on public.todo_lists for delete
to authenticated
using (user_id = (select auth.uid()));
auth.uid() 外面多包一層 select,是 Supabase 官方文件建議的寫法:這樣 Postgres 在整個查詢只算一次,而不是每一列都算一次,表越大,省下的次數越多。Security Advisor 的 Performance 分頁會用 auth_rls_initplan 提醒你沒包的 policy。
service_role 的 grant 是給後端和測試腳本用的。在新專案裡,連 service_role 也要明確 grant,否則用 secret key 一樣會得到 permission denied。
我自己的補習班排課 SaaS 用的是 Laravel,沒有 RLS,但面對的是同一個問題:每一家補習班的資料不能被別家看到。做法是一個 trait 加上一個 global scope:所有業務資料表都有 tenant_id,查詢時自動加上「只看目前這家補習班」的條件。
RLS 是「資料庫層」的牆,global scope 是「應用層」的牆。應用層的牆有幾個已知的缺口,AI 寫程式時特別容易踩到:
| 失效情境 | 原因 |
|---|---|
Model::withoutGlobalScopes() |
為了某個報表或後台功能方便,整個關掉 |
DB::table('courses')、raw SQL |
不經過 Eloquent model,scope 不存在 |
| Queue job、排程指令、外部 webhook | 沒有登入的使用者,「目前是哪一家補習班」沒被設定 |
第三種最危險,因為它牽涉到一個預設值:沒有設定「目前的補習班」時,scope 要怎麼做?
我的實作選的是「不加任何條件」,理由是平台管理後台和指令列需要跨補習班查詢。代價是在 queue job 裡,scope 會靜靜地失效,查詢回傳的是所有補習班的資料。我的專案有一個 job 負責把事件送到各補習班設定的 webhook 網址,它能正確運作,靠的是 job 裡手寫的一行 tenant_id 條件。少了這一行,某家補習班新增學生的資料,會送到所有訂閱同一種事件的補習班。
這一行手寫的條件就是唯一的一道牆,哪天有人忘了寫,就跟資料表沒開 RLS 一樣。比較安全的預設是反過來:沒有設定補習班,就拒絕查詢:
final class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
// Fail closed: a query without a tenant context is a bug, not "all tenants"
if (! app()->bound(Tenant::class)) {
throw new LogicException("No tenant bound while querying {$model->getTable()}.");
}
$builder->where($model->qualifyColumn('tenant_id'), app(Tenant::class)->id);
}
}
需要跨補習班查詢的地方(平台後台、每日排程),明確寫 withoutGlobalScope(TenantScope::class)。這樣每一個例外都看得見,也能用下面的測試一次盤點出來。這跟 RLS「開了但沒寫 policy 就全部擋下」是同一個設計:預設拒絕,例外要明確開。
下面用 Vitest+supabase-js,建立 Alice 和 Bob 兩個使用者,逐一測試「看不到、改不了、塞不進去、轉不走」:
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
import { beforeAll, describe, expect, it } from 'vitest'
// Point these at a local or staging project, never production
const url = process.env.SUPABASE_URL!
const publishableKey = process.env.SUPABASE_PUBLISHABLE_KEY!
const secretKey = process.env.SUPABASE_SECRET_KEY!
const noSession = { auth: { persistSession: false, autoRefreshToken: false } }
const admin = createClient(url, secretKey, noSession) // test setup only; bypasses RLS
const anon = createClient(url, publishableKey, noSession)
async function signUpAndSignIn() {
const email = `rls-${crypto.randomUUID()}@example.test`
const password = crypto.randomUUID()
const { data, error } = await admin.auth.admin.createUser({ email, password, email_confirm: true })
if (error) throw error
const client = createClient(url, publishableKey, noSession)
const { error: signInError } = await client.auth.signInWithPassword({ email, password })
if (signInError) throw signInError
return { client, id: data.user.id }
}
describe('todo_lists RLS', () => {
let alice: { client: SupabaseClient; id: string }
let bob: { client: SupabaseClient; id: string }
let aliceListId: number
let bobListId: number
beforeAll(async () => {
alice = await signUpAndSignIn()
bob = await signUpAndSignIn()
const { data, error } = await admin
.from('todo_lists')
.insert([
{ user_id: alice.id, title: 'alice list' },
{ user_id: bob.id, title: 'bob list' },
])
.select('id, user_id')
if (error) throw error
aliceListId = data.find((row) => row.user_id === alice.id)!.id
bobListId = data.find((row) => row.user_id === bob.id)!.id
})
it('lets owners read their own lists', async () => {
const { data, error } = await alice.client.from('todo_lists').select('id')
expect(error).toBeNull()
expect(data).toEqual([{ id: aliceListId }])
})
it('shows nothing to visitors who are not signed in', async () => {
const { data } = await anon.from('todo_lists').select('id')
expect(data ?? []).toHaveLength(0)
})
it('hides lists owned by other members', async () => {
const { data } = await alice.client.from('todo_lists').select('id').eq('id', bobListId)
expect(data).toEqual([])
})
it('refuses to create a list in someone else’s name', async () => {
const { error } = await alice.client
.from('todo_lists')
.insert({ user_id: bob.id, title: 'spam' })
expect(error?.code).toBe('42501')
})
it('leaves other members’ lists unchanged', async () => {
// RLS filters the row out, so this reports success while updating nothing
await alice.client.from('todo_lists').update({ title: 'hacked' }).eq('id', bobListId)
await alice.client.from('todo_lists').delete().eq('id', bobListId)
const { data } = await admin.from('todo_lists').select('title').eq('id', bobListId).single()
expect(data?.title).toBe('bob list')
})
it('refuses to transfer a list to someone else', async () => {
const { error } = await alice.client
.from('todo_lists')
.update({ user_id: bob.id })
.eq('id', aliceListId)
expect(error?.code).toBe('42501')
})
})
幾個細節:
data ?? []。舊專案有 grant 給 anon,會得到空陣列;新專案沒有 grant,會得到 permission denied、data 是 null。兩種都代表擋下了。admin 查,原因就是前面說的「成功但一筆都沒改」。我在本機用 PostgreSQL 17+PostgREST 模擬 Supabase 的角色實際跑過:上面的 policy 6 個測試全部通過;再故意把 policy 改壞(關掉 RLS、INSERT 只檢查有登入、SELECT 改成 using (true)、UPDATE 改成 with check (true)),每一種都至少有一個測試失敗。
Laravel 這邊,除了一般的跨租戶測試,還要盤點「哪些地方繞過了 scope」:
it('bypasses tenant scoping only in reviewed files', function () {
// Every entry here needs a reason in code review
$reviewed = [
'app/Http/Controllers/Platform/ImpersonateController.php',
];
$bypassing = collect(File::allFiles(app_path()))
->filter(fn ($file) => preg_match(
'/withoutGlobalScopes?\(|DB::(table|select|statement|unprepared)\(/',
$file->getContents(),
))
->map(fn ($file) => str($file->getPathname())->after(base_path().'/')->value())
->diff($reviewed)
->values()
->all();
expect($bypassing)->toBe([]);
});
AI 新增了一個 withoutGlobalScopes() 或 DB::table(),這個測試就會失敗,逼你決定:這個繞道合理嗎?合理的話加進清單,並在 code review 說明原因。我用同樣的規則掃自己的專案,找到 5 個檔案、9 處繞過 scope,每一處都得回答「為什麼可以」。
為什麼不用 Pest 的 arch 測試(->not->toUse('Illuminate\Support\Facades\DB'))?我實測後發現它不適合這件事:
DB::transaction(),但交易是正常需求,我的專案就有兩處。\DB::table() 這種別名寫法,把 'DB' 加進清單也一樣。withoutGlobalScopes(),因為那是方法呼叫,不是類別相依。「沒有設定補習班就拒絕」的 scope 本身,也用兩個測試鎖住:
beforeEach(function () {
$this->tenantA = Tenant::create(['name' => 'A']);
$this->tenantB = Tenant::create(['name' => 'B']);
Course::withoutGlobalScope(TenantScope::class)->insert([
['tenant_id' => $this->tenantA->id, 'name' => 'A course'],
['tenant_id' => $this->tenantB->id, 'name' => 'B course'],
]);
});
it('scopes queries to the bound tenant', function () {
app()->instance(Tenant::class, $this->tenantA);
expect(Course::pluck('name')->all())->toBe(['A course']);
});
it('refuses to query without a tenant', function () {
Course::count();
})->throws(LogicException::class);
以上 Laravel 程式碼在 Laravel 13.32+Pest 4.7 實際跑過。
省掉後端,不代表省掉檢查。前端能直接連資料庫時,資料庫的權限規則就是唯一的一道牆。每一道牆都要問:沒設定的時候,預設是開還是關?